OAuth state와 PKCE가 막아주는 공격

OAuth state와 PKCE가 막아주는 공격

한눈에 보기

state는 요청과 콜백을 연결해 CSRF를 줄이고 PKCE는 authorization code를 가로챈 공격자가 토큰으로 교환하지 못하게 한다.

목차

문제가 되는 상황

소셜 로그인은 브라우저가 우리 서비스와 Authorization Server 사이를 이동한다. 이 과정에서 callback URL은 외부 입력인 codestate를 받는다. 공격자가 자신의 로그인 callback을 피해자의 브라우저에 주입하거나, authorization code를 가로채 다른 client 인스턴스에서 토큰으로 교환하려 할 수 있다.

state와 PKCE는 모두 redirect 기반 흐름에 등장하지만 막는 공격과 검증 지점이 다르다. 값을 랜덤하게 만들었다는 사실만으로 안전해지는 것도 아니다. 시작 시점에 무엇과 묶어 저장했고 callback과 token endpoint에서 어떻게 소비하는지가 핵심이다.

이 글의 예제에 관하여

도메인, client ID, state와 verifier는 흐름을 설명하기 위한 가상 값이다. 실제 OAuth 공급자의 endpoint나 client secret을 사용하지 않았다.

Authorization Code 흐름부터 보기

sequenceDiagram
    participant U as Browser
    participant C as OAuth Client
    participant A as Authorization Server

    U->>C: 로그인 시작
    C->>C: state + verifier 생성·저장
    C-->>U: authorize URL로 redirect
    U->>A: state + code_challenge
    A->>U: 사용자 인증·동의
    A-->>U: callback?code=...&state=...
    U->>C: callback 요청
    C->>C: state 일치·일회성 검증
    C->>A: code + code_verifier로 token 요청
    A->>A: verifier에서 challenge 재계산
    A-->>C: token 응답

Authorization Code는 브라우저를 거쳐 client에 전달되지만 실제 토큰 교환은 client와 token endpoint 사이에서 일어난다. state는 브라우저가 돌아온 callback을 원래 로그인 시도와 연결하고, PKCE는 code를 처음 요청을 시작한 client 인스턴스가 가진 비밀 값과 연결한다.

state는 시작 요청과 callback을 연결한다

로그인 시작 시 예측할 수 없는 state를 만들고 현재 브라우저 세션에 저장한다.

async function beginLogin(request, response) {
  const state = crypto.randomBytes(32).toString("base64url");

  await loginAttemptStore.create({
    stateHash: sha256(state),
    browserSessionId: request.session.id,
    provider: "example-idp",
    returnPath: sanitizeReturnPath(request.query.returnPath),
    expiresAt: addMinutes(new Date(), 5),
  });

  return response.redirect(buildAuthorizeUrl({ state }));
}

callback에서는 query의 state가 저장한 시도와 일치하고, 같은 브라우저 세션에 속하며, 만료되지 않았고, 아직 사용되지 않았는지 확인한다.

const attempt = await loginAttemptStore.consume({
  stateHash: sha256(request.query.state),
  browserSessionId: request.session.id,
});

if (!attempt) {
  throw new OAuthCallbackError("INVALID_OR_EXPIRED_STATE");
}

state를 URL에 넣은 returnPath 자체로 쓰거나 서명 없이 JSON을 Base64로 넣으면 공격자가 외부 URL로 바꿀 수 있다. state는 서버 저장 레코드를 찾는 opaque 난수로 두고 이동 경로는 allowlist 또는 내부 상대 경로로 검증하는 편이 단순하다.

PKCE는 code를 client 인스턴스에 묶는다

PKCE를 사용할 때 client는 충분히 예측하기 어려운 code_verifier를 만들고 그 hash를 Base64URL로 표현한 code_challenge를 authorization 요청에 보낸다.

function createPkce() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto
    .createHash("sha256")
    .update(verifier, "ascii")
    .digest("base64url");

  return { verifier, challenge };
}
GET /authorize?response_type=code
  &client_id=example-client
  &redirect_uri=https%3A%2F%2Fapp.example.test%2Foauth%2Fcallback
  &state=random-state
  &code_challenge=generated-challenge
  &code_challenge_method=S256

Authorization Server는 code와 challenge를 연결해 저장한다. token 요청에서는 client가 verifier를 보낸다.

POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=one-time-code
&redirect_uri=https%3A%2F%2Fapp.example.test%2Foauth%2Fcallback
&client_id=example-client
&code_verifier=original-random-verifier

서버는 verifier로 challenge를 다시 계산해 처음 저장한 값과 비교한다. code만 가로챈 공격자는 verifier가 없어 token으로 교환하지 못한다. plain 대신 S256을 사용하고 verifier를 authorize URL이나 분석 로그에 넣지 않는다.

state와 PKCE는 서로 대체하지 않는다

기능 연결하는 대상 주로 줄이는 위협 검증 위치
state 로그인 시작과 callback login CSRF, 요청 혼합 OAuth client callback
PKCE authorization code와 client 인스턴스 code interception·주입 Authorization Server token endpoint

PKCE가 있더라도 callback이 사용자가 시작한 요청인지 검증하는 state 또는 동등한 세션 결합이 필요하다. state가 있더라도 code를 가로챈 공격자의 token 교환을 막는 PKCE 역할을 대신하지 않는다.

Authorization Code 자체도 짧게 만료하고 한 번만 사용하며 client ID와 redirect URI에 결합한다. 방어는 하나의 parameter가 아니라 흐름 전체의 제약을 겹치는 방식이다.

OIDC nonce와의 차이

OpenID Connect에서 nonce는 ID Token을 authorization 요청과 연결하고 replay를 줄이기 위해 사용한다. state와 이름이 다르지만 둘 다 랜덤 문자열이니 하나만 써도 된다고 생각하면 안 된다.

전달·검증 대상
state authorization 응답 query의 state를 client session과 비교
nonce ID Token claim의 nonce를 최초 요청 값과 비교
PKCE verifier token endpoint가 기존 code challenge와 비교

로그인이 OIDC라면 ID Token 서명, issuer, audience, expiration과 nonce를 검증한다. access token을 사용자 신원 정보로 해석하거나 ID Token을 Resource Server API 자격 증명으로 바꿔 쓰지 않는다.

서버에 무엇을 저장할까

로그인 시도 레코드를 명시하면 callback 검증이 단순해진다.

type LoginAttempt = {
  stateHash: string;
  browserSessionId: string;
  provider: string;
  pkceVerifierEncrypted: string;
  oidcNonceHash?: string;
  returnPath: string;
  createdAt: Date;
  expiresAt: Date;
  consumedAt?: Date;
};

verifier는 token 교환 전까지 필요한 비밀이므로 URL과 client-visible 로그에 남기지 않는다. 서버 세션 또는 보호된 단기 저장소에 둔다. state와 nonce는 원문 대신 hash를 저장할 수 있다. 레코드에는 짧은 만료와 일회성 소비를 적용하고 완료·실패 후 정리한다.

여러 탭에서 로그인을 동시에 시작할 수 있으므로 세션에 state 한 개만 덮어쓰면 첫 탭 callback이 실패한다. 로그인 시도별 레코드를 만들고 state를 키로 찾는다.

callback에서 검증하는 순서

callback 처리의 예시는 다음과 같다.

async function handleOAuthCallback(request, response) {
  if (request.query.error) {
    return handleProviderError(request.query);
  }

  const { code, state } = parseRequiredCallbackParams(request.query);
  const attempt = await consumeLoginAttempt({
    state,
    browserSessionId: request.session.id,
  });

  const tokens = await exchangeAuthorizationCode({
    code,
    codeVerifier: decrypt(attempt.pkceVerifierEncrypted),
    redirectUri: configuredRedirectUri,
  });

  const identity = await verifyIdToken(tokens.idToken, {
    issuer: configuredIssuer,
    audience: configuredClientId,
    nonceHash: attempt.oidcNonceHash,
  });

  await establishLocalSession(identity);
  return response.redirect(attempt.returnPath);
}

오류 응답에도 state가 올 수 있지만 공격자가 만든 값일 수 있으므로 동일하게 검증한다. 공급자가 준 오류 설명을 그대로 HTML에 출력하지 않고 안전한 사용자 메시지와 내부 로그를 분리한다.

redirect URI와 open redirector

Authorization Server는 등록된 redirect URI와 요청 값을 정확하게 비교해야 한다. wildcard나 부분 문자열 검증은 공격자 경로로 code가 전달될 여지를 만든다.

Client callback 뒤의 returnPath도 외부 URL을 허용하면 open redirector가 된다.

function sanitizeReturnPath(value: unknown): string {
  if (typeof value !== "string") return "/";
  if (!value.startsWith("/") || value.startsWith("//")) return "/";
  return value;
}

https://trusted.example.test.attacker.test처럼 문자열 prefix만 비슷한 URL을 통과시키지 말고 URL parser와 정확한 origin allowlist를 사용한다.

실전 점검 목록

OAuth redirect 흐름

  • state가 충분한 난수이며 브라우저 세션·provider와 결합되어 있는가?
  • state와 authorization code가 한 번만 사용되고 짧게 만료되는가?
  • PKCE에 S256과 충분한 verifier를 사용하는가?
  • verifier가 URL, 로그, 분석 도구에 노출되지 않는가?
  • OIDC라면 nonce와 ID Token claim을 별도로 검증하는가?
  • redirect URI가 등록 값과 정확히 일치하는가?
  • callback 이후 return path가 내부 허용 경로인지 확인하는가?

state는 요청과 콜백을 연결해 CSRF를 줄이고 PKCE는 authorization code를 가로챈 공격자가 토큰으로 교환하지 못하게 한다.

결론

state는 로그인 시작과 callback을 같은 브라우저 세션의 일회성 시도로 연결하고, PKCE는 authorization code를 verifier를 가진 client 인스턴스에 결합한다. 둘은 서로 다른 위협을 줄이므로 함께 사용한다. OIDC nonce, 정확한 redirect URI, code의 일회성·짧은 만료, 안전한 return path까지 검증해야 redirect 기반 로그인 흐름 전체가 닫힌다.

관련 노트